今天大概會聊到的範圍
- Animation
- Modifier.graphicsLayer
上一篇講到 Animation,今天想要拿 Animation 來實作看看。
再跑敏捷的過程中,常常需要舉牌投點。我都會用 Scrum Time 這個 App 來進行。在這個 App 中,點數牌打開時有一個漂亮的翻牌動畫。今天,我想用 Compose Animation 來試圖達到一樣的效果。

在開始之前,我先做出基本的牌面
@Composable
fun CardBack() {
    Card(
        modifier = Modifier
            .aspectRatio(.65f)
            .defaultMinSize(minHeight = 60.dp),
        backgroundColor = Color.Blue,
        border = BorderStroke(width = 16.dp, color = Color.White)
    ) {
    
    }
}
@Composable
fun CardFront() {
    Card(
        contentColor = SpotiColor.Black,
        backgroundColor = Color.coverColor3,
        border = BorderStroke(width = 16.dp, color = Color.White),
        modifier = Modifier
            .aspectRatio(.65f)
            .defaultMinSize(minHeight = 60.dp)
    ) {
        Box(modifier = Modifier.fillMaxSize()) {
            Text(
                text = "Front",
                fontSize = 36.sp,
                fontWeight = FontWeight.Black,
                color = Color.White,
                textAlign = TextAlign.Center,
                modifier = Modifier.align(Alignment.Center)
            )
        }
    }
}
| CardFront() | CardBack() | 
|---|---|
|  |  | 
在動畫之前,我想先瞭解如何讓我的卡片“看起來”像是有翻轉。
先說到旋轉,最常見的旋轉是繞著 Z 軸(垂直於螢幕平面的軸)旋轉。我們可以透過 Modifier.rotate 來修飾元件達到這件事情。
@Preview
@Composable
fun PreviewCardFront() {
    Box(modifier = Modifier.rotate(30f)) {
        CardFront()
    }
}

rotate 內部的實作,其實是透過另一個 Modifier graphicsLayer。
fun Modifier.rotate(degrees: Float) =
    if (degrees != 0f) graphicsLayer(rotationZ = degrees) else this
graphicsLayer 這個 modifier 可以對他修飾的 component 進行圖形上的改動。例如放大縮小、旋轉、形狀與透明度。
fun Modifier.graphicsLayer(
    // 放大縮小
    scaleX: Float = 1f,
    scaleY: Float = 1f,
    // 透明度
    alpha: Float = 1f,
    // 位移
    translationX: Float = 0f,
    translationY: Float = 0f,
    // 陰影
    shadowElevation: Float = 0f,
    // 繞著不同軸旋轉
    rotationX: Float = 0f,
    rotationY: Float = 0f,
    rotationZ: Float = 0f,
    cameraDistance: Float = DefaultCameraDistance,
    transformOrigin: TransformOrigin = TransformOrigin.Center,
    shape: Shape = RectangleShape,
    clip: Boolean = false,
    renderEffect: RenderEffect? = null
): Modifier
除了 rotate 外,Modifier.clip、Modifier.alpha 和 Modifier.scale 等 modifier 也都是對 graphicsLayer 的包裝。
回到這次的目標,我們需要繞著 Y 軸選轉我們的牌。因此,我們透過 graphicsLayer 並且設定 rotationY。
@Preview
@Composable
fun PreviewCardFront() {
    Box(modifier = Modifier.graphicsLayer(rotationY = 30f)) {
        CardFront()
    }
}

知道卡片該怎麼轉之後,就可以開始來做動畫了。
// 背景
Box(
    modifier = Modifier
        .fillMaxSize()
        .background(color = darkBgColor)
) {
    // Card 包裝
    Box(
        modifier = Modifier
            .fillMaxSize(.6f)
            .align(Alignment.Center)
    ) {
        CardFront()
    }
}
我希望在點擊卡片後,卡片就會翻轉。因此,在卡片上 ( Box 那一層 ) 增加一個 clickable 的 modifier。點擊後,修改目前應該要是正面或反面的 state。
enum class CardState { Front, Back }
// in Composable
var state by remember { mutableStateOf(CardState.Front) }
// 背景
Box(
    modifier = Modifier
        .fillMaxSize()
        .background(color = darkBgColor)
) {
    // Card 包裝
    Box(
        modifier = Modifier
            .fillMaxSize(.6f)
            .align(Alignment.Center)
            .clickable {
                state = when (state) {
                    CardState.Front -> CardState.Back
                    CardState.Back -> CardState.Front
                }
            }
    ) {
        CardFront()
    }
}
上次有提到,我們可以用 updateTransition 來將 state 轉成 transition。可以透過同一個 transition 來控制不同動態的值。
var state by remember { mutableStateOf(CardState.Front) }
val flipTransition = updateTransition(targetState = state)
// 正面時不翻轉,反面時翻轉 180 度
val rotateY by flipTransition.animateFloat {
    when (it) {
        CardState.Front -> 0f
        CardState.Back -> 180f
    }
}
// 背景
Box(
    modifier = Modifier
        .fillMaxSize()
        .background(color = darkBgColor)
) {
    // Card 包裝
    Box(
        modifier = Modifier
            .fillMaxSize(.75f)
            .align(Alignment.Center)
            .clickable {
                state = when (state) {
                    CardState.Front -> CardState.Back
                    CardState.Back -> CardState.Front
                }
            }
            .graphicsLayer {
                rotationY = rotateY       // <-- 使用 rotateY 這個參數當作 rotationY 的值
            }
    
    ) {
        CardFront()
    }
}

在翻轉時,會發現卡的邊邊會被削掉。因為虛擬的 "攝影機" 和實際元件的距離短於卡片寬度的一半,導致卡片翻轉時,卡片的邊會超過虛擬攝影機的鏡頭。
當我們要做 rotationY / rotationX 時,都建議在 graphicsLayer 加上 cameraDistance,設定一個大於卡片寬度的距離。
.graphicsLayer {
    rotationY = rotateY
    cameraDistance = DefaultCameraDistance * density
}

因為要翻牌,我們希望卡片在翻到 90 度的時候,顯示的內容由卡面換成卡背。這個部分我一樣可以透過 ratateY 這個值來做判斷
if (rotateY <= 90f) {
    CardFront()
} else {
    CardBack()
}

到目前為止,翻轉卡片的動畫已經大致上完成。但是還想要改變幾個部分:
在 transition 轉成 animation 時,或是 animateXXXAsState 等 function 時,都可以加入 transitionSpec,transitionSpec 可以調整動畫的影格對應到參數的變動速率。
val rotateY by flipTransition.animateFloat(
    transitionSpec = {
        tween(
            delayMillis = 50,
            durationMillis = 500,
            easing = LinearOutSlowInEasing
        )
    }
) {
    when (it) {
        CardState.Front -> 0f
        CardState.Back -> 180f
    }
}
常用的 spec 有:
spring:數值 A 到數值 B 的曲線會類似彈簧一樣(可以設定彈力係數和力道),當力道強的時候會來回彈跳tween:可以設定數值 A 到 B 的時間,並且透過 easing 設定數值變動的曲線keyframes:設定每個關鍵影格所代表的值(以 ms 為單位)repeatable:數值會在 A B 之間不斷重複,到某個固定的值。還有 infiniteRepeatable 可以無限重複snap:會瞬間將數值 A 轉變成數值 B
透過一樣的概念,我們可以透過 transition 獨立出另一個數值  scale。並且透過 keyframe 達到讓 Scale 進行 大 > 小 > 大 的動畫。
val scale by flipTransition.animateFloat(
    transitionSpec = {
        keyframes {
            durationMillis = 500
            .6f at 250 with LinearEasing
        }
    }
) {
    when (it) {
        CardState.Front -> 1f
        CardState.Back -> 1f
    }
}
keyframe 中,可以透過 <數值> at <影格 (ms)> with <easing> 來設定影格。start 會動畫啟動當下的值,end 會是 targetValue  ( 後面提供的 lambda 所提供的值 )
.graphicsLayer {
    rotationY = rotateY
    cameraDistance = DefaultCameraDistance * density
    scaleX = scale
    scaleY = scale
}
最後,在 graphicsLayer 可以設定 scaleX、scaleY 依照 scale 這個值縮放

卡片翻轉的動畫就這樣完成了!透過實作範例比較能了解複雜的 Animation 中各種工具的參數。今天的範例還是有很多部分沒有接觸到,未來也許可以再回頭來看看。
Reference: